-
Notifications
You must be signed in to change notification settings - Fork 1
Detailed balance #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Detailed balance #52
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #52 +/- ##
============================================
+ Coverage 0.00% 91.35% +91.35%
============================================
Files 1 2 +1
Lines 2 81 +79
============================================
+ Hits 0 74 +74
- Misses 2 7 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Co-authored-by: rozyczko <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea with separating utilities from the rest of the code.
A few points added on how to improve the code.
The rest looks good!
src/easydynamics/utils/utils.py
Outdated
""" | ||
|
||
# Input validation | ||
if not isinstance(divide_by_temperature, bool): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type checks already test the types. If we enforce those, these checks aren't necessary
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is a user-facing function, input validation is always a good idea since you have no guarantee that the users have any clue what they're doing :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So it's mostly to be used internally, but I can't rule out that users will want to use it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should be perfectly clear wether a function/method is user-facing or not.
For user-facing functions you need input validation checks like these and thoroughly documented docstrings.
For developer/hidden functions you don't as you always know what you pass to the function. Also, those methods should always start with an _.
@rozyczko Just double checking, is it OK to merge? :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good now!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will continue tomorrow
src/easydynamics/utils/utils.py
Outdated
""" | ||
|
||
# Input validation | ||
if not isinstance(divide_by_temperature, bool): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is a user-facing function, input validation is always a good idea since you have no guarantee that the users have any clue what they're doing :)
src/easydynamics/utils/utils.py
Outdated
|
||
# Input validation | ||
if not isinstance(divide_by_temperature, bool): | ||
raise ValueError("divide_by_temperature must be a boolean.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would write "must be True or False" rather than "must be boolean", slightly easier to understand for non-programmers ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, this should be a type error
src/easydynamics/utils/utils.py
Outdated
# Convert to numpy array first for consistent handling | ||
if isinstance(value, (int, float)): | ||
array_value = np.array(value) | ||
elif isinstance(value, (list, tuple)): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you also allow tuples? That is not shown in the type hints
src/easydynamics/utils/utils.py
Outdated
DBF_values = DBF.values | ||
return DBF_values | ||
|
||
def _convert_to_scipp_variable(value: Union[int, float, list, np.ndarray, Parameter, sc.Variable], unit: str, name: str = "value") -> sc.Variable: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is unit a mandatory field? Especially since you reuse the unit if the input is a parameter?
src/easydynamics/utils/utils.py
Outdated
return sc.scalar(value=float(array_value.flat[0]), unit=unit) | ||
else: | ||
# Multi-element array | ||
return sc.array(dims=['x'], values=array_value, unit=unit) No newline at end of file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this method meant to take multi-dimensional lists too? Won't you need more than 1 dim then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not at the moment, no. It may also be that I need to revisit the dims if/when I move entirely to scipp for x, but I'll park that with the other headaches from the other PR for now :)
src/easydynamics/utils/utils.py
Outdated
raise ValueError("energy_unit must be a string.") | ||
|
||
if not isinstance(temperature_unit, str): | ||
raise ValueError("temperature_unit must be a string.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here
src/easydynamics/utils/utils.py
Outdated
raise ValueError("Temperature must be non-negative.") | ||
|
||
# Convert energy to sc variable to make units easy to handle | ||
energy = _convert_to_scipp_variable(energy, energy_unit, "energy") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message here would say that energy can be a Parameter, which it can't according to your type hints.
src/easydynamics/utils/utils.py
Outdated
# We give users the option to specify the unit of the energy, but if the input has a unit, they might clash | ||
if energy.unit != energy_unit: | ||
warnings.warn(f"Input energy has unit {energy.unit}, but energy_unit was set to {energy_unit}. Using {energy.unit}.") | ||
energy_unit = energy.unit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what you're doing here. I think I would do this slightly differently. Rather than having an energy_unit
at all, I would simply raise a warning stating that the input was assumed to be in meV if it has no units. If the user just blindly passes a numpy array right now, they would not be informed that it was assumed to be in meV automatically.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're informed in the docstring - is that enough?
Args:
energy : number, list, np.ndarray, or scipp Variable. If number, assumed to be in meV unless energy_unit is set.
Energy transfer
src/easydynamics/utils/utils.py
Outdated
# Same for temperature | ||
if temperature.unit != temperature_unit: | ||
warnings.warn(f"Input temperature has unit {temperature.unit}, but temperature_unit was set to {temperature_unit}. Using {temperature.unit}.") | ||
temperature_unit = temperature.unit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And again, the same here.
src/easydynamics/utils/utils.py
Outdated
# Zero temperature deserves special treatment | ||
if temperature.value == 0: | ||
if divide_by_temperature: | ||
raise ValueError("Cannot divide by T when T=0.") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can raise a specific ZeroDivisionError here ;)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
More comments
|
||
def _convert_to_scipp_variable( | ||
value: Union[int, float, list, np.ndarray, Parameter, sc.Variable], | ||
unit: Optional[str], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type hinting the unit argument does not make it optional :)
You need to add a default value for it, like None
, if you wish to make it optional.
# Scalar or single-element array | ||
try: | ||
return sc.scalar(value=float(array_value.flat[0]), unit=unit) | ||
except sc.UnitError as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you imported the UnitError
from scipp, you don't need the "sc." prefix here or below :)
energy < 0.0 * sc.Unit(energy_unit), 0.0 * sc.Unit(energy_unit), energy | ||
) | ||
|
||
if DBF.sizes == {}: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
. . . . Does scipp return an empty dictionary when you get sizes on an empty array? O.o
# Small x (small energy and/or high temperature): Taylor expansion | ||
small = sc.abs(x) < SMALL_THRESHOLD | ||
|
||
DBF = sc.where(small, 1 + x / 2 + x**2 / 12, DBF) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
List comprehension
DBF = sc.where(large, x, DBF) | ||
|
||
# General case: exact formula | ||
mid = ~small & ~large |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
~ should be not
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nvm. remove tildes and replace & with ==
|
||
# General case: exact formula | ||
mid = ~small & ~large | ||
DBF = sc.where(mid, x / (1 - sc.exp(-x)), DBF) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Write comment: zeros handles by small treshold
Temperature | ||
energy_unit : str, optional | ||
Unit for energy if energy is given as a number or list. Default is 'meV' | ||
temperature_unit : str, optional |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it refers to the formula instead of the function parameter
warnings.warn( | ||
f"Input energy has unit {energy.unit}, but energy_unit was set to {energy_unit}. Using {energy.unit}." | ||
) | ||
energy_unit = energy.unit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not use energy.unit in code below after converting to sc? That way, energy_unit will only be an argument for convert function and there won't be two variables with potentially different contents referring to essentially the same entity. Same for temperature
# Large x (large energy and/or low temperature): asymptotic form | ||
large = x > LARGE_THRESHOLD | ||
DBF = sc.where(large, x, DBF) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is x in small
casted to abs, but not in large?
array_value = np.array(value.value) | ||
unit = value.unit | ||
else: | ||
if name == "energy": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if value is Parameter and name is "energy" it would be checked out by the elif isinstance(value, Parameter):
in the upper part, this specific if statement for "energy" avoiding Parameter type does not make sense
def _convert_to_scipp_variable( | ||
value: Union[int, float, list, np.ndarray, Parameter, sc.Variable], | ||
unit: Optional[str] = None, | ||
name: str = "value", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the default value is not used anywhere except error messages and it's not marked as optional.
imho it's a redundant parameter since you'll see the variable name that calls _convert_to_scipp_variable
in the traceback anyway
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Final comments to the tests now. The code itself looks good.
from easyscience import Parameter | ||
from scipp.constants import Boltzmann as kB | ||
|
||
from easydynamics.utils import _detailed_balance_factor as detailed_balance_factor |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't remove the underscore in imports. At least not in code.
temperature_unit = 5 | ||
# Then Expect | ||
with pytest.raises(TypeError, match="temperature_unit must be a string."): | ||
detailed_balance_factor(energy, T, temperature_unit=temperature_unit) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is a good idea to test multiple wrong inputs, not just a single one (here, you chose an int)
def test_scalar_input(self): | ||
# When | ||
energy = 2.0 | ||
T = 100 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Considering how often you set energy and temperature in your # When, it might be worth making this as a fixture to reuse throughout your tests.
energy / (1 - np.exp(-energy / (kB_meV_per_K * T))) / (kB_meV_per_K * T) | ||
) | ||
|
||
np.testing.assert_allclose(result, expected, rtol=1e-5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since you're testing for a scalar input, it probably makes sense to test that the output is also a scalar? :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's an array, but good point
|
||
assert isinstance(result, np.ndarray) | ||
assert result.shape == (3,) | ||
np.testing.assert_allclose(result, expected_values, rtol=1e-5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All of these tests could have been a single parametrized test with the energy input parametrized
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will keep this in mind for future tests ;)
def test_negative_temperature_raises(self): | ||
# When Then Expect | ||
with pytest.raises(ValueError, match="Temperature must be non-negative"): | ||
detailed_balance_factor(1.0, -10) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The temperature tests can be collected and parametrized and the exception tests can also be collected and parametrized.
) | ||
# Expect | ||
expected = np.full(5, kB_meV_per_K * T) | ||
np.testing.assert_allclose(result, expected, rtol=1e-5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't the expected value here also change with energy? Why are you simply comparing to a constant?
The way you're testing it right now, with a relative tolerance equal to your highest energy, you're not really testing anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good find, I messed that test up.
tests/unit_tests/utils/test_utils.py
Outdated
energy=energy, temperature=T, divide_by_temperature=False | ||
) | ||
# Expect | ||
np.testing.assert_allclose(result, energy, rtol=1e-2) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm also not sure this test makes much sense. In the high-energy limit you set DBF = x. So shouldn't you just check that they're literally equal here? Instead of testing that they're close?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They differ on like 13th decimal, probably due to floating point things. I made the test much stricter.
|
||
# Expect | ||
expected_ratio = np.exp(energy / (kB_meV_per_K * T)) | ||
np.testing.assert_allclose(ratio, expected_ratio, rtol=1e-5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe add a comment for the reasoning behind this test? It is not obvious why the ratios should fulfill this condition. Think about your future programmers :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But everyone knows the principle of detailed balance, right? (I've added a comment)
expected = energy.values / ( | ||
1 - np.exp(-energy.values / 1000 / (kB_meV_per_K * T)) | ||
) | ||
np.testing.assert_allclose(result, expected, rtol=1e-5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't have a test of a faulty unit such as "m" or "l"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In both this and the temperature unit. I also don't think you have a test where you pass a sc.Unit?
Utility function to calculate the detailed balance factor as discussed here: easyscience/dynamics#6
Includes the option to normalise by temperature. Takes various inputs and outputs a np array. I may want to later output a scipp array, but not yet.